"""
    jupylet/app.py
    
    Copyright (c) 2020, Nir Aides - nir@winpdb.org

    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice, this
       list of conditions and the following disclaimer.
    2. Redistributions in binary form must reproduce the above copyright notice,
       this list of conditions and the following disclaimer in the documentation
       and/or other materials provided with the distribution.

    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
    ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
    ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
    (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
    ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""


import ipywidgets
import asyncio
import logging
import pickle
import random
import time
import glm
import sys
import io

import PIL.Image
import concurrent.futures

import numpy as np

import moderngl
import moderngl_window as mglw

try:
    from multiprocessing import shared_memory
except:
    shared_memory = None

from .resource import register_dir, set_shader_2d, set_shader_3d, set_context
from .env import is_remote, is_osx, set_window_size, is_python_script, is_rl_worker
from .env import parse_args
from .color import c2v
from .clock import ClockLeg, Timer, setup_fake_time
from .event import EventLeg, JupyterWindow
from .utils import Dict, o2h, abspath, callerpath, patch_method
from .utils import get_logging_widget, setup_basic_logging

from .audio import midi


#__all__ = ['App']


logger = logging.getLogger(__name__)


REMOTE_WARNING = '游戏视频将被压缩，并可能包括明显的卡顿和延迟，因为它是从远程服务器流式传输的。这是意料之中的。如果你在电脑上安装Jupylet，视频质量会很高。'


def get_config_dict(config_cls):
    return {
        k: getattr(config_cls, k) for k in dir(config_cls) 
        if k[0] != '_' and not callable(getattr(config_cls, k))
    }


def _clear(
    self, 
    red=0.0, 
    green=0.0, 
    blue=0.0, 
    alpha=1.0, 
    depth=1.0, 
    viewport=None, 
    color=None, 
    foo=None
):

    if color:
        return foo(*c2v(color, alpha).rgba)

    return foo(red, green, blue, alpha, depth, viewport)


class App(EventLeg, ClockLeg):
    
    """Jupylet游戏对象。

    Args:
        width (int): 游戏视图的宽，单位像素/pixels 。
        height (int): 游戏视图的高，单位像素/pixels 。
        mode (str): 游戏模式可以是 'jupyter' 表示在Jupyter notebook 下运行游戏, 
            'window' 表示在Python脚本环境运行游戏，  'hidden' 针对机器学习(不
            用界面), 或者 'auto' 表示将由 Jupylet 自动选择合适的模式。
        resource_dir (path): 游戏资源目录的路径。
        quality (int): 当Jupyter笔记本在远程服务器上运行时，游戏视频压缩质量被
            指定为10到100之间的整数。
    """

    def __init__(
        self, 
        width=512, 
        height=512, 
        mode='auto', 
        resource_dir='.', 
        quality=None,
        **kwargs
    ):
        """"""

        if mode == 'auto':
            if is_rl_worker():
                mode = 'hidden'
            elif is_python_script():
                mode = 'window'
            else:
                mode = 'jupyter'

        assert mode in ['window', 'jupyter', 'hidden']
        assert not (is_remote() and mode == 'window')

        if is_remote() and mode =='jupyter' and quality is None:
            quality = 20
            sys.stderr.write(REMOTE_WARNING + '\n')
            self._show_remote_warning = True

        self._use_shm = False
        self._shm = None

        self._width = width
        self._height = height

        self.window_size = (width, height)
        
        set_window_size(self.window_size)

        conf = Dict(get_config_dict(self))

        # pyglet可能会在osx上崩溃。
        if conf.window == 'pyglet' and is_osx():
            conf.window = 'glfw'

        conf.update(kwargs)

        if mode == 'window' and is_python_script():
            for k, v in vars(parse_args()).items():
                if v is not None:
                    conf[k] = v

        for k, v in conf.items():
            self.__dict__[k] = v

        setup_basic_logging(conf.log_level)
        
        #logger.warning('创建宽度为 %r、高度为 %r 且具有以下配置的应用程序对象：%r', width, height, conf)

        if mode in ['jupyter', 'hidden']:
            window_cls = JupyterWindow

        elif mode == 'window':
            window_cls = mglw.get_local_window_cls(conf.window)

        # 通过缩放glfw窗口来监控DPI。
        if conf.window == 'glfw':
            import glfw
            glfw.init()
            glfw.window_hint(0x0002200C, True)

        self.window = window_cls(size=(width, height), **conf)
        self.print_context_info(self.window)

        patch_method(self.window, 'clear', _clear)

        mglw.activate_context(window=self.window)

        if mode == 'hidden':
            self.fake_time = setup_fake_time()

        self.timer = Timer()

        ClockLeg.__init__(self, timer=self.timer)

        EventLeg.__init__(
            self,
            ctx=self.window.ctx, 
            wnd=self.window, 
            timer=self.timer
        )
        
        self.mode = mode

        self.buffer = b''
        self.canvas = None
        
        if mode == 'jupyter':

            empty = _ime(PIL.Image.new('RGB', (width, height)))
            self.canvas = ipywidgets.Image(value=empty, format='JPEG')
            self.canvas_quality = quality or 85
            self.canvas_interval = 1 / 24 if self.canvas_quality > 50 else 1 / 12

            self.window._watch_canvas(self.canvas)

        self._run_timestamp = None

        self.is_running = False
        self.interval = 1/48
        self.ndraws = 0

        register_dir(resource_dir, callerpath())
        register_dir(abspath('assets')) 

        set_context(self.ctx)
        self.ctx.enable_only(moderngl.BLEND)

        #max_textures = self.ctx.info['GL_MAX_TEXTURE_IMAGE_UNITS']

        set_shader_3d(self.load_program(
            vertex_shader='shaders/default-vertex-shader.glsl',
            fragment_shader='shaders/default-fragment-shader.glsl',
        ))

        shader = set_shader_2d(self.load_program('shaders/sprite.glsl'))
        shader['projection'].write(glm.ortho(
            0, width, 0, height, -1, 1
        ))

        self._time2draw = 0
        self._time2draw_rm = 0

    def __del__(self):

        if self._shm is not None:
            self._shm.close()
            self._shm.unlink()

    def print_context_info(self, window):
        """输出moderngl上下文信息。"""
        logger.info("Context Version:")
        logger.info("ModernGL: %s", moderngl.__version__)
        logger.info("vendor: %s", window._ctx.info["GL_VENDOR"])
        logger.info("renderer: %s", window._ctx.info["GL_RENDERER"])
        logger.info("version: %s", window._ctx.info["GL_VERSION"])
        logger.info("python: %s", sys.version)
        logger.info("platform: %s", sys.platform)
        logger.info("code: %s", window._ctx.version_code)

        # Consume potential glerror from querying info 
        # 从查询信息中消除潜在的错误
        err = window._ctx.error
        if err != "GL_NO_ERROR":
            logger.info("获取上下文信息后消耗的glerror: %s", err)

    def set_event_handler(self, name, func):

        if name == 'midi_message':
            midi.set_midi_callback(func)
            self.scheduler.unschedule(midi.midi_port_handler)
            self.scheduler.schedule_interval(midi.midi_port_handler, 1)
            return

        EventLeg.set_event_handler(self, name, func)

    def set_midi_sound(self, s):
        """启动默认的MIDI处理程序，将给定的声音对象作为其工具。
        
        Args:
            s (jupylet.audio.sound.Sound): 用作MIDI乐器的声音对象。
        """
        if midi.test_rtmidi():
            
            midi.set_midi_sound(s)
            midi.set_midi_callback(midi.simple_midi_callback)
            
            self.scheduler.unschedule(midi.midi_port_handler)
            self.scheduler.schedule_interval(midi.midi_port_handler, 1)

    @property
    def width(self):
        """bool: 游戏画布的宽度（以像素为单位）。"""
        return self._width

    @property
    def height(self):
        """bool: 游戏画布的高度（以像素为单位）。"""
        return self._height
    
    def run(self, interval=1/30):
        """开始运行游戏。
        
        如果游戏在Jupyter笔记本中运行，调用将立即返回画布(canvas)对象，游戏帧将
        在画布对象上重新绘制，否则调用将被阻止，直到游戏完成。

        Args:
            interval (float): 帧重画之间的间隔（以秒为单位）。

        Returns:
            ipywidgets.widgets.widget_media.Image: 将在其上重新绘制游戏的ipywidgets对象。
        """
        #assert self.window._context, 'Window has closed. Create a new app object to run.'

        hasattr(self, '_show_remote_warning') and sys.stderr.write(REMOTE_WARNING + '\n')

        if self.canvas:
            interval = max(interval, self.canvas_interval)

        self.set_redraw_interval(interval)

        if not self.is_running:

            self._run_timestamp = time.time()
            self.timer.start()

            self.is_running = True
            self._exit = False        

            loop = asyncio.get_event_loop()
            task = loop.create_task(self._run())

            if not loop.is_running():
                loop.run_until_complete(task)

        return self.canvas
        
    async def _run(self):
        
        while not self._exit and not self.window.is_closing:            
            dt = self.scheduler.call()
            await asyncio.sleep(max(0, dt - 0.0005))
        
        self.is_running = False
        self.timer.pause()

    def start(self, interval=1/20):
        
        assert self.mode == 'hidden', 'start() is only possible in "hidden" reinforcement learning mode. Use run() instead.'
        
        if self.canvas:
            interval = max(interval, self.canvas_interval)

        self.set_redraw_interval(interval)

        if not self.is_running:

            self.timer.start()

            self.is_running = True
            self._exit = False        
           
        return self.canvas
       
    def step(self, n=1):

        assert self.mode == 'hidden', 'step() is only possible in "hidden" reinforcement learning mode. Use run() instead.'
        
        ndraws = self.ndraws

        while self.is_running and not self._exit and self.ndraws < ndraws + n:
            
            dt = self.scheduler.call()
            self.fake_time.sleep(dt + 1e-4)
            
        if self._exit:
            self.is_running = False

    def stop(self, foo=None):
        """停止指定的处理程序运行。如果没有指定处理程序，则停止整个游戏。
        
        Args:
            foo (function): 要被停止运行的处理程序。
        """
        if foo is not None:
            return self.unschedule(foo, levels_up=2)

        if self._run_timestamp and time.time() - self._run_timestamp < 0.5:
            sys.stderr.write('Ignoring call to stop() since it appears to have been done accidentally.')
            return

        if self.is_running:
            self.scheduler.unschedule(self._redraw_windows)
            self._exit = True

    def set_redraw_interval(self, interval):

        self.interval = interval
        self.scheduler.unschedule(self._redraw_windows)

        if interval > 0:
            self.scheduler.schedule_interval(self._redraw_windows, interval)

    def _redraw_windows(self, ct, dt):
        
        t0 = time.time()
        
        self.window.render(ct, dt)
        self.window.swap_buffers()
        
        self._time2draw = time.time() - t0
        self._time2draw_rm = self._time2draw_rm * 0.95 + self._time2draw * 0.05

        if self.mode != 'window':
            self.buffer = self.window.fbo.read(components=4)
        
        if self.mode == 'jupyter':

            im0 = _b2i(self.buffer, self.window.fbo.size, 'RGBA').convert('RGB')

            self.canvas.value = _ime(
                im0, 
                'JPEG', 
                quality=self.canvas_quality
            )
            
        self.ndraws += 1

    def observe(self):
        """将游戏画布内容作为倒置翻转的 numpy 数组返回。
        
        Returns:
            numpy.ndarray: 游戏画布的内容是一个Numpy数组，其形状为H x W x 4，对应于RGBA位图图像。
        """
        w, h = self.window.fbo.size
        b = self.get_buffer() 

        if self._use_shm:
            shm = self.get_shared_memory()
            shm.buf[:] = b
            return ('__ndarray__', (w, h, 4), 'uint8', shm.name)

        return np.frombuffer(b, dtype='uint8').reshape(h, w, -1)

    def get_buffer(self):

        w, h = self.window.fbo.size
        if w * h * 4 != len(self.buffer):
            self.buffer = self.window.fbo.read(components=4)

        return self.buffer

    def use_shared_memory(self):
        if shared_memory is not None:
            self._use_shm = True

    def get_shared_memory(self):

        w, h = self.window.fbo.size
        size = w * h * 4

        if self._shm is not None and self._shm.size != size:
        
            self._shm.close()
            self._shm.unlink()
            self._shm = None

        if self._shm is None:
            self._shm = shared_memory.SharedMemory(create=True, size=size)

        return self._shm

    # TODO: check if this still works 
    # TODO: 检查这是否仍然有效
    def scale_window_to(self, px):
        """调整窗口大小，使其最大尺寸（宽度或高度）为px像素。

        这对于RL应用程序很有用，因为较小的窗口渲染速度更快。

        Args:
            px (int): 重新缩放画布的目标大小（以像素为单位）。
        """
        #assert self.mode == 'hidden', 'Can only scale hidden window.'
        assert self.is_running, 'Window can only be scaled once app has been started.'

        w, h = self.window.fbo.size 
        s = px / max(w, h)

        self.window.create_framebuffer(s * w, s * h)

    def save_state(self, name, path=None, *args):
        """将指定游戏对象的状态保存到磁盘

        Args:
            name (str): 如果没有给出显式路径，'name' 将用于自动生成文件名
            path (str, optional): 用于文件的显式路径。
            *args: 实现 save_state() 方法以保存到磁盘的对象列表。

        Returns:
            str: 保存文件的路径。
        """
        if not path:
            path = '%s-%s.state' % (name, o2h(random.random()))

        with open(path, 'wb') as f:
            sl = [o.get_state() for o in args]
            pickle.dump(sl, f)
            
        return path
            
    def load_state(self, path, *args):
        """从磁盘加载给定游戏对象的状态。

        加载所有对象后，还会渲染场景。

        Args:
            path (str, optional): 用于文件的显式路径。
            *args: 实现load_state() 方法的要从磁盘加载的对象列表。
        """
        with open(path, 'rb') as f:
            sl = pickle.load(f)
            for o, s in zip(args, sl):
                o.set_state(s)

        self._redraw_windows(0, 0)

    def get_logging_widget(self, height='256px', quiet_default_logger=True):
        """返回用作日志消息输出的ipywidget。

        Returns:
            ipywidgets.widgets.widget_output.Output: 用作日志消息输出的ipywidget。
        """
        return get_logging_widget(height, quiet_default_logger)
       

def _b2i(buffer, size, format='RGBA'):
    """将字节缓冲区转换为PIL图像。"""
    return PIL.Image.frombytes(format, size, buffer, 'raw', format, 0, -1)


def _ime(im, format='JPEG', **kwargs):
    """使用指定格式对numpy数组进行图像编码。"""

    b0 = io.BytesIO()
    im.save(b0, format, **kwargs)
    return b0.getvalue()

